Redis 常见面试题
什么是 Redis
- 基于内存 的数据库,读写速度快,常用于 缓存、消息队列、分布式锁 等场景
- 提供多种数据类型支持不同业务场景,由于执行命令由单线程负责,不存在并发竞争的问题,因此 对数据类型的操作都是原子性 的
- Redis 还支持 事务、持久化、Lua 脚本、集群(主从复制、哨兵、切片集群)、订阅/发布模式、内存淘汰机制、过期删除机制 等
Redis 和 Memcached 有什么区别
- 共同点
- 基于内存,一般用作缓存
- 都有过期策略
- 高性能
- 区别
- Redis 数据类型更加丰富,Memcached 只支持最简单的 key-value 数据类型
- Redis 支持数据持久化,重启/宕机后数据不会丢失
- Redis 支持原生集群模式,Memcached 没有原生集群模式,需要依靠客户端实现
- Redis 支持 订阅/发布模式、Lua 脚本、事务等功能
Redis 数据结构
- 常见数据类型及实现
- String: 缓存对象、常规计数、分布式锁、共享 session
- List: 消息队列(1.生产者需要自行实现全局唯一 ID;2.不能以消费组形式消费数据)
- Hash: 缓存对象、购物车
- Set: 聚合计算(交并差集)场景,比如点赞、共同关注、抽奖等
- Zset: 排序场景,比如排行榜、电话和姓名排序等
- 其他数据类型
- BitMap: 二值状态统计场景,比如迁到、判断用户登陆状态、连续签到用户总数等
- HyperLogLog: 海量数据基数统计的场景,比如百万级网页 UV 计数等
- GEO: 存储地理位置信息的场景,比如滴滴打车
- Stream: 消息队列,相比基于 List 类型实现的消息队列,具有自动生成全局唯一 ID、支持消费组形式消费数据 的特点
Redis 线程模型
- Redis 单线程指的是 「接收客户端请求 -> 解析请求 -> 进行数据读写等操作 -> 发送数据给客户端」 这个过程是由一个线程(主线程)来完成的
但是 Redis 程序并非单线程,Redis 在启动时会启动 后台线程(BIO) 用于处理其他任务(关闭文件、AOF刷盘、释放内存等)
之所以为这些任务单独创建线程进行处理,是因为这些任务的操作都是很耗时的,放在主线程中处理,容易阻塞 Redis 主线程,导致后续请求无法处理 - 关闭文件、AOF刷盘、释放内存 这三个任务都有各自的任务队列
- BIO_CLOSE_FILE: 关闭文件任务队列,当队列中有任务后,后台线程会调用 close(fd) 将文件关闭
- BIO_AOF_FSYNC: AOF刷盘任务队列,当 AOF日志 配置成 everysec 后,主线程会将AOF 写日志操作封装成一个任务放到队列中,后台线程会调用 fsync(fd) 将 AOF文件 刷盘
- BIO_LAZY_FREE: lazy free 任务队列,当队列中有任务后,后台线程会调用对应的方法释放内存, free(obj) 释放对象、free(dict) 删除数据库所有对象、free(skiplist) 释放调表对象
- 单线程工作模型
Redis 单线程为什么还这么快
- Redis 大部分操作都是在 内存中完成 的,并且采用了 高效的数据结构,因此 Redis 的瓶颈是机器的内存或者网络带宽
- Redis 采用单线程模型,可以避免多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会出现死锁问题
- Redis 采用了 I/O 多路复用机制 处理大量的客户端 socket 请求
Redis 为什么使用单线程
- Redis 6.0 之前使用的是单线程进行主要工作(网络 I/O 和 执行命令)
因为 CPU 并不是制约 Redis 性能表现的瓶颈所在,Redis 的瓶颈更多情况下是受到 内存大小和网络I/O 的西安至,因此 Redis 的核心网络模型采用单线程实现
使用单线程后,可维护性高,多线程模型虽然某些方面表现优异,但是引入了程序执行顺序的不确定性,带来的并发读写等一系列问题,增加了系统复杂度,同时可能存在线程切换、加锁解锁、甚至死锁造成的性能消耗 - Redis 6.0 之后为什么引入多线程
在 Redis 6.0 之后,采用了多个 I/O 线程处理网络请求,因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在 网络I/O 的处理上
因此为了提高 Redis 网络I/O 的并行度,Redis 6.0 采用了多线程处理 网络I/O,但是对于命令的执行仍然使用单线程处理
因此在 6.0 版本后 Redis 启动时默认会在主线程外额外创建 6 个线程- BIO_CLOSE_FILE、BIO_AOF_FSYNC、BIO_LAZY_FREE 三个后台线程
- IO_THD_1、IO_THD_2、IO_THD_3: 三个 I/O 线程,用于分担 Redis 网络I/O 的压力
Redis 持久化
AOF 日志
每执行一条写操作命令,就把该命令以追加的方式写入到一个文件
Redis 先执行写操作命令,然后将该命令记录到 AOF 日志中
这么做的好处:- 避免额外的检查开销: 如果先写入日志再执行,则需要对命令进行语法检查,避免错误命令记录到日志中,导致恢复出错
- 不会阻塞当前写操作命令的执行: 因为写操作命令执行成功后,才会记录到日志中
对应的风险:
- 数据可能丢失: 执行写操作命令和记录日志是两个过程,如果服务器在两个过程中间宕机,那么就会有丢失数据的风险
- 可能阻塞其他操作: 写操作命令执行成功后才记录到 AOF 日志,不会阻塞当前操作,但是AOF 日志也在主线程中执行,所以可能阻塞后续操作
AOF 的写回策略
- Redis 写入 AOF 日志的过程
- Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区
- 然后通过 write() 系统调用将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而其实拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘
- 具体内核缓冲区的数据什么时候写入硬盘,由内核决定
- 回写策略
- Always: 每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘
- Everysec: 每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区中的内容写回硬盘
- No: 不由 Redis 控制写回硬盘的时机,转交给操作系统控制,即每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘
- Redis 写入 AOF 日志的过程
AOF 重写机制
AOF 文件过大时会带来性能问题,当 AOF 文件大小超过阈值时就会启动 AOF 重写机制进行压缩
AOF 重写机制在重写时,会读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到 新的 AOF 文件,等到全部记录完后,对现有的 AOF 文件进行替换
- 重写过程
AOF 的重写过程由后台子进程bgrewriteaof
完成,有如下优点- 子进程进行 AOF重写 期间,主进程可以继续处理命令请求,避免阻塞
- 使用子进程而非线程,因为多线程之间会共享内存,在修改共享内存数据时就需要加锁保证数据安全,会导致性能降低
而使用子进程时,父子进程是 共享内存数据 的,但是共享的内存数据只能用只读的方式,如果父子进程任意一方修改了该共享内存,就会发生 写时复制,于是父子进程就拥有了各自独立的数据副本,不用加锁保证数据安全
- AOF重写缓冲区
在重写过程中,主进程仍然可以正常处理命令,因此重写过程中如果主进程修改了已经存在的 key-value,就会发生写时复制,此时子进程的内存数据和主进程的内存数据不一致
因此 Redis 设置了AOF重写缓冲区
,这个缓冲区在创建了bgrewriteaof
子进程后开始使用,在重写期间,Redis 执行完一个写命令后,会同时将这个写命令写入到AOF缓冲区
和AOF重写缓冲区
,当子进程完成 AOF重写 工作后,会将 AOF重写缓冲区 中的所有内容追加到新的 AOF 文件中,使新旧两个 AOF文件 所保存的数据库状态一致
- 重写过程
RDB 快照
- 将某一时刻的内存数据以二进制的方式写入磁盘
AOF日志 记录的是操作命令而非实际数据,RDB快照 记录某一瞬间的内存数据,即实际数据,因此在 Redis 恢复数据时,RDB 的效率会比 AOF 更高 - 是否阻塞线程
Redis 提供save
和bgsave
两个命令生成 RDB 文件,区别就在于是否在主线程内执行,save
会在主线程中生成 RDB文件,如果写入时间太长会阻塞主线程,bgsave
会创建子进程生成 RDB文件,避免主线程的阻塞
RDB快照 是全量快照,频率太高可能影响性能,太低则可能在故障时丢失更多数据 - 执行快照时修改数据
在执行bgsave
时 Redis 可以继续处理操作命令修改数据,关键在于写时复制技术
执行bgsave
时通过fork()
创建子进程,此时父子进程共享同一片内存数据,因为创建子进程时会复制父进程的页表,但是页表指向的物理内存还是同一个,此时如果主线程执行读操作,主线程和子进程互不影响
如果主线程执行写操作,则被修改的数据会复制一份副本,然后bgsave
子进程会把该副本写入 RDB 文件,这个过程中主线程仍然可以直接修改原来的数据
混合持久化 Redis 4.0 新增,集合 AOF 和 RDB 优点
- RDB 优点是数据恢复速度快,但是快照频率不好把握,AOF 优点是丢失数据少,但是数据恢复慢,因此提出混合持久化,保证重启速度并降低数据丢失风险
- 混合持久化工作在 AOF日志重写 过程,开启混合持久化之后,AOF日志重写时,fork 出来的子进程会先将与主线程共享的内存数据以 RDB 方式写入 AOF文件,然后主线程处理的操作命令会被记录在 重写缓冲区 中,重写缓冲区 中的增量命令会以 AOF 方式写入 AOF 文件,写入完成后统治主进程用新的含有 RDB格式 和 AOF格式 的 AOF文件 替换旧的 AOF文件
即采用混合持久化之后,AOF 文件前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据
重启时前半部分为 RDB,加载速度快,加载完 RDB 内容后才会加载后半部分的 AOF 内容,后半部分的 AOF 内容为 Redis 后台子进程重写 AOF 期间的主线程的操作命令,可以使减少数据丢失风险 - 优点: 结合了 RDB 和 AOF 的优点,使 Redis 启动更快,同时减少数据丢失风险
缺点: 1. 可读性差,AOF 文件中添加了 RDB 格式的内容;2. 兼容性差,缓和持久化的 AOF 文件不能用于 Redis 4.0 之前的版本
Redis 集群
主从复制 Redis 高可用服务最基础的保证,将一台主服务器的数据同步到多台从服务器上,主从服务器之间采用 读写分离 方式
主服务器可以进行读写操作,发生写操作时自动将写操作同步给从服务器
从服务器一般为只读,并接受主服务器同步过来的写操作并执行
所有的数据修改只在主服务器上进行,然后将最新数据同步给从服务器达到数据一致
由于主从服务器之间的命令复制是 异步 进行的,因此 无法实现强一致性保证哨兵模式
使用 Redis 主从服务,服务器出现故障宕机时需要手动进行恢复,因此 Redis 增加了哨兵模式,用于监控主从服务器,并 提供主从节点故障转移功能
切片集群模式
大部分 Redis 缓存的数据量达到一台服务器无法缓存时,需要使用 Redis切片集群(Redis Cluster) 方案,将数据分布在不同的服务器上,以此降低系统对主从节点的以来,从而提高 Redis 服务的读写性能
Redis切片集群 采用 哈希槽(Hash Slot) 处理数据和节点之间的关系, Redis Cluster 方案中一个切片集群共有 16384 个哈希槽,哈希槽类似于数据分区,每个键值对都会根据它的 key 被映射到一个哈希槽里
具体执行过程:
- 根据键值对的 key,使用 CRC16 算法计算一个 16 bit 的值
- 用这个值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽
映射方案:
- 平均分配: 在使用 cluster create 命令创建 Redis 集群时,Redis 会自动把所有哈希槽平均分配到集群节点上,比如集群中有 9 个节点,则每个节点上槽的个数为 19384/9 个
- 手动分配: 使用 cluster meet 命令手动建立节点间的连接,组成集群,再使用 cluster addsolts 命令指定每个节点上的哈希槽个数,手动分配时,需要把 16384 个哈希槽都分配完,否则集群无法正常工作
集群脑裂
由于网络问题,主节点失去联系,主从数据不同步,此时哨兵进行选举,导致产生两个主节点。等网络恢复,旧主节点会降级为从节点,重新与新主节点进行同步复制,从节点第一次同步会清空自身缓冲区,所以导致之前客户端写入数据丢失
解决方案
当主节点发现从节点下线或者通信超时的总数量小于阈值时,禁止主节点写入数据,返回错误给客户端
相关参数min-slaves-to-write x
: 主节点必须要有至少 x 个从节点连接,小于 x 时主节点禁止写数据min-slaves-max-lag x
: 主从数据复制和同步的延迟不超过 x 秒,如果超过,主节点禁止写数据
这两个配置组合后,主库连接的从库中至少需要有 N 个从库,和主库畸形数据复制时的 ACK 消息延迟不能超过 T 秒,否则主库不会再接受客户写请求
即使原主库假故障,在此期间无法响应哨兵心跳,也无法和从库进行同步,此时原主库就会被限制接受写请求,客户端不能在原主库中写入新数据,等到新主库上线时,就只有新主库能接收和处理客户端请求,此时新写的数据会被直接写入新主库,原主库降级为从库,即使清空了缓冲区,也不会造成新数据丢失
Redis 过期删除和内存淘汰
Redis 的过期删除策略
- 过期键值对删除策略: Redis 可以对 key 设置过期时间,因此需要相应的机制删除已过期键值对,Redis 使用的策略是
惰性删除 + 定期删除
配合使用 - 过期字典: 每当对一个 key 设置过期时间,Redis 会把该 key 带上过期时间存储到一个 过期字典(expires dict) 中,过期字典中保存了数据库中所有 key 的过期时间
- 惰性删除
不主动删除过期键,每次访问 key 时检测是否过期,如果过期则删除
优点: 每次访问时才会检查 key 是否过期,因此只会使用少量的系统资源,对 CPU 时间最友好
缺点: 如果一个 key 已经过期,而又保留在数据库中,只要一直没被访问,所占用的内存就不会释放,造成内存空间浪费,对于内存不友好 - 定期删除
每隔一段时间,随机从数据库中取出一定数量的 key 进行检查,并删除其中的过期 key
定期删除是一个循环的流程,Redis 为了保证定期删除不会出现循环过度导致线程卡死,增加了定期删除的时间上限,默认不会超过 25 ms
优点: 通过限制删除操作执行的时长和频率,减少删除操作对 CPU 的影响,同时也能删除一部分过期数据,减少了过期键对空间的无效占用
缺点: 难以确定删除操作执行的时长和频率,过于频繁对 CPU 不友好,次数过少则趋近于惰性删除,内存不友好
Redis 持久化时如何处理过期键
- RDB 快照
RDB 文件分为 生成阶段 和 加载阶段- 生成阶段: 从内存状态持久化成 RDB 快照文件时,会对 key 进行过期检查,过期的键不会被保存到新的 RDB 文件中
- 加载阶段: RDB 加载阶段分别对应以下两种情况
主服务器模式: 载入 RDB 文件时,程序会对文件中保存的键进行检查,过期键不会被载入到数据库中
从服务器模式: 载入 RDB 文件时无论键是否过期都会被载入到数据库,但由于主从服务器数据同步时,从服务器的数据会被清空,因此过期键也不会对从服务器造成影响
- AOF 日志
AOF 日志分为 写入阶段 和 AOF 重写阶段- AOF 文件写入阶段: 当 Redis 以 AOF 模式持久化时,如果某个过期键还没有被删除,AOF 文件会被保留此过期键,当此过期键被删除后,Redis 会向 AOF 文件中追加一条 DEL 命令显式删除此键值对
- AOF 重写阶段: 执行 AOF 重写时会对 Redis 中的键值对进行检查,已过期的键不会被保存到重写后的 AOF 文件中,因此不会对 AOF 重写造成影响
主从模式下如何处理过期键
- Redis 运行在主从模式下时,从库不会进行过期扫描,从库对于过期键的处理是被动的,即使从库中的 key 过期了,客户端依然可以从从库中访问到对应的键值对
从库的过期键处理依赖于主库,主库在 key 过期时会在 AOF 文件中添加一天 DEL 命令同步到所有的从库,从库通过执行这条指令删除过期的 key
Redis 内存满了会发生什么
- Redis 的运行内存达到某个阈值就会触发内存淘汰机制,这个阈值就是配置文件中设置的最大运行内存
maxmemory
Redis 的内存淘汰策略
Redis 的内存淘汰策略共八种,这八种策略大体分为 不进行数据淘汰 和 进行数据淘汰 两类策略
不进行数据淘汰的策略
noeviction: Redis 3.0 之后默认,表示当运行内存超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误
进行数据淘汰的策略
进行数据淘汰的策略又可以细分为 在设置了过期时间的数据中进行淘汰
和 在所有数据范围内进行淘汰
- 在设置了过期时间的数据中进行淘汰
- volatile-random: 随机淘汰设置了过期时间的任意键值
- volatile-ttl: 优先淘汰更早过期的键值
- volatile-lru: Redis 3.0 之前默认,淘汰所有设置了过期时间的键值中 最久未使用 的键值
- volatile-lfu: Redis 4.0 之后新增,淘汰所有设置了过期时间的键值中 最少使用 的键值
- 在所有数据范围内进行淘汰
- allkeys-random: 随机淘汰任意键值
- allkeys-lru: 淘汰所有键值中 最久未使用 的键值
- allkeys-lfu: Redis 4.0 之后新增,淘汰所有键值中 最少使用 的键值
LRU 和 LFU 算法的区别
LRU(Least Recently Used)
最近最少使用,会选择淘汰最近最少使用的数据
传统 LRU 算法 实现基于 链表结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可
但存在两个问题- 需要使用链表管理所有缓存数据,会带来额外的空间开销
- 当有数据被访问时,需要将该数据移动到链表头部,如果有大量数据被访问,会带来很多链表移动操作,十分耗时,导致 Redis 性能降低
近似 LRU 算法 是 Redis 采用的实现方式,目的是为了节约内存
实现方式是在 Redis 的对象结构体中添加一个额外的字段用于记录此数据的 最后一次访问时间,当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,随机取 5 个值(可配置),然后淘汰最久没有使用的键值
优点:- 不用为所有数据维护一个大链表,节省空间占用
- 不用在每次数据访问时移动链表元素,提高 Redis 性能
存在问题
- 无法解决 缓存污染问题,比如应用一次性读取了大量数据,而这些数据只会被读取一次,这些数据被留存在 Redis 中很长一段时间,造成缓存污染
LFU(Least Frequently Used)
- 最近最不常使用,LFU 算法根据数据访问次数淘汰数据,核心思想是
如果数据过去被多次访问,那么将来被访问的频率也更高
Redis 4.0 之后引入 LFU 解决缓存污染问题 - 实现 lru 字段在 LRU 和 LFU 算法下使用方式并不相同。在 LRU 中用于记录 key 的访问时间戳,在 LFU 中 lru 字段被分成两段进行存储,高 16bit 存储
// Redis 对象结构
typedef struct redisObject {
...
// 24 bits,用于记录对象的访问信息
unsigned lru:24;
...
} robj;ldt(Last Decrement Time)
记录 key 的访问时间戳,低 8bit 存储logc(Logistic Counter)
记录 key 的访问频次
Redis 缓存设计
缓存雪崩
大量缓存数据在同一时间过期(失效)时,如果此时有大量用户请求,都无法在 Redis 中处理,所有请求直接访问数据库,从而导致数据库压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩
解决方案
- 将缓存失效时间随机打散 在失效时间上设置一个随机值
- 设置缓存不过期: 通过后台服务更新缓存数据,从而避免缓存失效造成的缓存雪崩
缓存击穿
缓存中的某个 热点数据过期 了,此时大量请求访问该热点数据,会直接访问数据库,容易冲垮数据库,这就是缓存击穿,缓存击穿可以理解为缓存雪崩的一个子集
解决方案
- 互斥锁方案: 使用 setnx 设置一个状态位,表示锁定状态,保证同一时间只有一个业务线程请求缓存,未能获取互斥锁的请求等待锁释放后重新读取缓存,或者返回空值/默认值
- 热点数据不设置过期时间,由后台线程更新缓存
缓存穿透
用户访问的数据不存在缓存中,也不存在数据库中,导致请求访问时无法构建缓存数据来服务后续的请求,当有大量请求时,数据库压力骤增,这就是缓存穿透
发生情况
- 业务误操作: 缓存和数据库中的数据都被误操作删除了,所以导致缓存和数据库中都没有数据
- 黑客恶意攻击: 黑客故意大量访问某些读取不存在数据的业务
解决方案
- 限制非法请求: 在 API 入口处判断请求参数是否合理、请求参数是否含有非法值、请求字段是否存在等,如果判断为恶意请求则直接返回错误,避免进一步访问缓存和数据库
- 设置空值或者默认值: 当线上业务发现缓存穿透现象时,可以针对查询的数据在缓存中设置默认值或者空值,后续请求就不会直接查询数据库
- 布隆过滤器: 在写入数据库数据时使用布隆过滤器做标记,在用户请求到来时,业务线程确认缓存失效后通过查询布隆过滤器快速判断数据是否存在数据库中,避免查询数据库判断数据是否存在。大量请求只会查询 Redis 和布隆过滤器,保证数据库的正常运行
动态缓存热点数据
- 总体思路
通过数据最新访问时间做排名,并过滤不常访问的数据,只留下经常访问的数据
例子: 电商平台场景中,只要求缓存用户经常访问的 TOP1000 的商品- 先通过缓存系统做一个排序队列(比如存放 1000 个商品),系统根据商品访问时间更新队列信息,访问时间越新的商品排名越靠前
- 同时系统会定期过滤队列中排名最后的 200 个商品,再从数据库中随机读取出 200 个商品加入队列
- 当每次请求到达时,先从队列中获取商品 ID,如果命中,就根据 ID 从另一个缓存数据结构中读取实际的商品信息并返回
常见的缓存更新策略
Cache Aside(旁路缓存)
策略、Read/Write Through(读穿/写穿)
策略 、Write Back(写回)
策略
Cache Aside
最常用的策略,应用程序直接和数据库、缓存交互,并负责对缓存的维护,又可以细分为 读策略 和 写策略
写策略: 先更新数据库中的数据,再删除缓存中的数据,顺序不能颠倒,否则 读写并发 时会出现数据不一致问题
读策略: 如果读取的数据命中缓存,则直接返回数据;如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入缓存并且返回给用户
Cache Aside 策略 适用于读多写少 的场景,不适用于写多的场景,因为写入频繁时,缓存中的数据会被频繁清理,影响缓存命中率,如果业务对于缓存命中率有严格要求,可以考虑以下解决方案
- 在更新数据并更新缓存前使用分布式锁,保证同一时间只有一个线程更新缓存,不会产生并发问题,但会影响写入性能
- 给缓存加一个较短的过期时间,这样即使数据不一致,缓存的数据也会很快过期,对于业务影响在可接受范围内
Read/Write Through
应用程序只和缓存交互,不再与数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作由缓存代理
Read Thought 策略: 先查询缓存中数据是否存在,存在则直接返回,不存在则由缓存组件负责从数据库查询数据并将结果写入缓存,最后缓存组件将数据放回给应用
Write Thought 策略: 当有数据更新时,先查询要写入的数据在缓存中是否已存在。存在则更新缓存中的数据,并由缓存组件更新数据库后告知应用程序更新完成;缓存中不存在时直接更新数据库然后返回
此策略特点在于由缓存节点而非应用程序来和数据库进行交互,常用的分布式缓存组件 Memcached 和 Redis 都不提供写入数据库和自动加载数据库数据的功能,因此比较少见,但是在使用本地缓存时可以考虑此策略
Write Back
在更新数据时只更新缓存,同时将缓存数据设置为脏数据并立刻返回,不更新数据库,对于数据库的更新通过批量异步更新的方式进行
使用上 写回策略 不能应用在常用的数据库和缓存场景,因为 Redis 原生不支持异步更新数据库的功能
写回策略 是计算机体系结构中的设计,比如 CPU缓存、文件系统缓存都采用了写回策略
此策略特别适合写多的场景,因为发生写操作时只需要更新缓存就能返回结果,比如写文件的时候,是直接写入到文件系统的缓存就返回,并不会写磁盘
存在的问题: 数据不是强一致性的,且存在数据丢失风险
缓存数据一致性(待续)
Redis 实战
实现延迟队列
延迟队列就是把当前要做的事情往后推迟一段时间再做
常见场景
- 购物平台下单,未付款超时自动取消
- 打车平台,规定时间内无司机接单,自动取消订单并提醒
- 外卖平台,卖家规定时间未接单自动取消
Redis 中可以使用有序集合(ZSet) 实现延迟队列,使用 Score 属性存储延迟执行的时间
使用 zadd score1 value1
命令生产消息,再利用 zrangebyscore
查询符合条件的所有待处理任务,循环执行队列任务
bigkey 如何处理
- bigkey: 指 key 对应的 value 很大,一般以下两种情况称为 bigkey
- String 类型的值大于 10kb
- Hash、List、Set、ZSet 类型的元素超过 5000 个
- 影响
- 客户端超时阻塞: 操作 bigkey 比较耗时,由于 Redis 单线程执行命令,可能阻塞 Redis,客户端得不到响应就会超时
- 引发网络阻塞: 每次获取 bigkey 产生的网络流量较大,如果一个 key 对应的 value 为 1MB,每秒访问量为 1000,即每秒产生 1000MB 的流量
- 阻塞工作线程: 如果使用 del 删除大 key,可能阻塞工作线程,导致后续命令无法处理
- 内存分布不均: 集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有 bigkey 的 Redis 节点占用内存多,QPS 也会比较大
查找大 key
redis-cli -h host -p port -a "password" --bigkeys
注意事项- 使用时最好在从节点上执行,在主节点执行可能阻塞主节点
- 没有从节点时尽量在 Redis 实例业务压力低峰时进行扫描查询,或者使用 -i 参数控制扫描间隔,避免长时间扫描降低 Redis 性能
缺点
- 只能返回每种类型中最大的 bigkey,无法得到其他 bigkey
- 对于集合元素来说,这个方法只统计集合元素个数,而非实际占用内存。一个集合中元素个数多并不意味着内存占用多
使用 SCAN 命令
使用 SCAN 扫描数据库,然后使用 TYPE 获取返回的每一个 key 的类型进行判断
对于 string 类型可以直接使用 STRLEN 获取字符串长度,即占用的内存空间字节数
对于集合类型则有两种方法- 如果能预先从业务层得知集合元素平均大小,可以使用命令获取集合元素个数,然后乘以集合元素平均大小来获得集合占用内存
List类型 -> LLEN | Hash类型 -> HLEN | Set类型 -> SCARD | Sorted Set类型 -> ZCARD
- 如果不能预先获取集合元素平均大小,可以使用
MEMORY USAGE
(需要 Redis 4.0 以上版本),查询一个键值对占用的内存空间
- 如果能预先从业务层得知集合元素平均大小,可以使用命令获取集合元素个数,然后乘以集合元素平均大小来获得集合占用内存
RdbTools 工具
使用第三方开源工具 RdbTools 解析 RDB 快照文件,找到 bigkey
rdb dump.rdb -c memory --bytes 10240 -f redis.csv
删除 bigkey
删除操作的本质是释放键值对占用的内存空间
释放内存是第一步,为了更高效管理地管理内存空间,操作系统需要将释放掉的内存块插入一个空闲内存块的链表,方便后续管理和再分配,这个过程需要一定的时间,而且阻塞当前释放内存的应用程序,因此如果在短时间内释放大量内存,空闲内存块链表操作时间就会增加,相应的会造成 Redis 主线程阻塞,主线程阻塞可能导致其他请求超时
删除方案
分批次删除
对于 Hash: 使用 hscan 命令,每次获取 100 个字段,每次删除 1 个字段
对于 List: 通过 ltrim 命令,每次删除少量元素
对于 Set: 使用 sscan 命令,每次扫描集合中 100 个元素,再用 srem 命令每次删除 1 个键
对于 ZSet: 使用 zermrangebyrank 命令,每次删除 Top 100 个元素异步删除
从 Redis 4.0 版本开始,可以采用异步删除,用 unlink 命令代替 del 进行删除
除了主动调用 unlink,还可以通过配置参数在达到某些条件时自动进行异步删除
场景lazyfree-lazy-eviction
: 当 Redis 运行内存超过 maxmeory 时,是否开启 lazy free 机制lazyfree-lazy-expire
: 设置了过期时间的键值对,过期之后是否开启 lazy free 机制lazyfree-lazy-server-del
: 有些指令在处理已存在的键时,会带有一个隐式的 del 键操作,比如 rename 命令,当目标键已存在时,会先删除目标键,如果目标键是一个 bigkey,就有可能造成阻塞,这个配置表示此时是否开启 lazy free 机制slave-lazy-flush
: 针对 slave 进行全量数据同步,slave 在加载 master 的 RDB 文件前,会运行 flushall 来清理数据,这个配置表示此时是否开启 lazy free 机制
lazyfree-lazy-eviction、lazyfree-lazy-expire、lazyfree-lazy-server-del
等配置建议开启,可以有效提高主线程执行效率
Redis 管道有什么用
管道技术(Pipeline)是 客户端提供 的一种批处理技术,用于一次处理多个 Redis 命令,从而提高整个交互的性能
使用管道技术可以解决 多个命令执行时的网络等待,它把多个命令整合到一起发送给服务端处理之后统一返回给客户端,免去了每条命令执行后都要等待的情况,从而有效提高程序的执行效率,但使用管道技术也要注意 避免发送的命令过大,或 管道内的数据太多导致的管道阻塞
Redis 事务支持回滚吗
Redis 不提供回滚机制,虽然 Redis 提供了 DISCARD 命令,但只能用于主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果
Redis 如何实现分布式锁
使用 set 命令的 NX 参数可以实现 key 不存在才插入,因此可以用它实现分布式锁
- 如果 key 不存在,则显示插入成功,用于表示加锁成功
- 如果 key 存在,则显示插入失败,用于表示加锁失败
基于 Redis 实现分布式锁时,对于加锁操作需要满足的三个条件 SET lock_key unique_value NX PX 10000
- 加锁包括读取锁变量、检查锁变量值、设置锁变量值三个操作,但是需要以原子操作的方式完成,所以使用 SET 命令带 NX 参数实现加锁
- 锁变量需要实现设置过期时间,以免客户端拿到锁后发生异常导致锁无法释放,因此需要加上 EX/PX 选项设置过期时间
- 锁变量的值需要能区分来自不同客户端的加锁操作,避免释放锁时出现误释放操作,因此使用 SET 设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端
优点
- 性能高效,核心出发点
- 实现方便
- 避免单点故障,集群部署
缺点
- 超时时间不好设置,时间过长影响性能,过短会无法保护共享资源
可以基于续约的方式设置合理的超时时间,但是实现相对复杂 - 主从模式中的数据复制是异步复制的,导致分布式锁的不可靠性,主节点在同步前宕机,有可能导致新节点依旧可以获取锁,导致多个应用同时获取同一个资源的锁
Redlock(红锁)
基于多个 Redis 节点的分布式锁,即使有节点发生故障,锁变量依然存在,客户端还是可以完成锁操作,官方推荐至少部署 5 个节点,且均为主节点,各主节点之间没有任何关系,相互孤立
基本思路: 让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能跟半数以上的节点成功完成加锁操作,那就认为客户端能成功获取锁,否则加锁失败
Redlock 算法加锁过程
- 客户端获取当前时间(t1)
- 客户端按顺序依次向 N 个 Redis 节点执行加锁操作
- 加锁操作使用 SET 命令,带上 NX、EX/PX 选项以及客户端唯一标识
- 如果某个 Redis 节点发生故障,为了保证 Redlock 算法能够继续运行,需要给加锁操作设置一个超时时间,加锁操作的超时时间需要远远小于锁的过期时间,一般为几十毫秒
- 一旦客户端从超过半数的 Redis 节点上成功获取锁,就再次获取当前时间(t2),然后计算整个加锁过程总耗时(
t2 - t1
),如果t2 - t1
大于锁的过期时间,此时认为客户端加锁成功,否则认为加锁失败
总结: 加锁成功需要同时满足两个条件,从超过半数的 Redis 节点上成功获取锁,并且总耗时没有超过锁的有效时间
加锁成功后客户端需要重新计算锁的有效时间,如果计算的结果已经来不及完成共享内存的操作,就要对锁进行释放,避免出现还没完成数据操作锁就过期的情况
加锁失败后,客户端向所有 Redis 节点发起释放锁的操作